Skip to content

feat: Multiselect#131

Open
sebastiangrill wants to merge 5 commits intohunvreus:developmentfrom
sebastiangrill:development
Open

feat: Multiselect#131
sebastiangrill wants to merge 5 commits intohunvreus:developmentfrom
sebastiangrill:development

Conversation

@sebastiangrill
Copy link
Copy Markdown

Hey,

I am using basecoat and I needed a multiselect feature for my current project so I extended the current select to support it.
My JS skills are pretty limited so I probably did some unconvential (wrong) things.
Relevant: #99
Please give me some feedback and I can make adjustments.

image

I have added a few new data options:

Listbox Data Attributes

aria-multiselectable -> This has to be set true to indicate this is a multi-select component
data-multiselect-threshold -> Threshold beyond which selected items are condensed into a count display instead of showing individual badges
data-multiselect-threshold-text -> Custom text to append after the count when threshold is exceeded (e.g., "items chosen"), defaults to entries selected
data-multiselect-closetext -> A button is added to the listbox with the text 'clear' that clears all options displayed. This changes the text
aria-controls -> ID of an external element to populate with selected items (instead of using the default selectedLabel)

Option Data Attributes

Every option has to have an aria-selected or it is ignored: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-multiselectable#used_with_aria-selected

data-multiselect-variant -> Badge styling variant. Allowed values: 'secondary', 'destructive', 'ghost', 'outline'. If an invalid variant is specified or none is provided, it defaults to 'primary'.
data-multiselect-display -> ID of a element to display instead of a badge. When set, this element is cloned, the hidden attribute removed and used as the display rather than creating a badge. The referenced id can be anywhere.

JS change event

The multiselect sends all current checked values in the change event as a JSON Array

Example

<div id="select-303718" class="select w-40">
<button type="button" class="btn-outline justify-between font-normal" id="select-303718-trigger" aria-haspopup="listbox" aria-expanded="false" aria-controls="select-303718-listbox">
   	<span>Select Framework</span>
	<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0">
      <path d="m7 15 5 5 5-5" />
      <path d="m7 9 5-5 5 5" />
    </svg>
  </button>
  <div id="select-303718-popover" data-popover aria-hidden="true">
    <header>
      <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search">
        <circle cx="11" cy="11" r="8" />
        <path d="m21 21-4.3-4.3" />
      </svg>
      <input type="text" value="" placeholder="Search framework..." autocomplete="off" autocorrect="off" spellcheck="false" aria-autocomplete="list" role="combobox" aria-expanded="false" aria-controls="select-303718-listbox" aria-labelledby="select-303718-trigger" />
    </header>

    <div role="listbox" id="select-303718-listbox" aria-orientation="vertical" aria-labelledby="select-303718-trigger" data-empty="No framework found." aria-multiselectable="true" data-multiselect-threshold="3" data-multiselect-threshold-text="einträge">
      <div role="option" data-value="Next.js" aria-selected="false">Next.js</div>
      <div role="option" data-value="SvelteKit" aria-selected="false" data-multiselect-variant="secondary">SvelteKit</div>
      <div role="option" data-value="Nuxt.js" aria-selected="false" data-multiselect-variant="destructive">Nuxt.js</div>
      <div role="option" data-value="Remix" aria-selected="false" data-multiselect-display="select-303718-externaldisplay">Remix</div>
      <div role="option" data-value="Astro" aria-selected="false">Astro</div>
    </div>
  </div>
  <input type="hidden" name="select-303718-value" value="" />
</div>

<div hidden>
	<svg id="select-303718-externaldisplay" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smile-icon lucide-smile"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/></svg>
</div>

@hunvreus
Copy link
Copy Markdown
Owner

hunvreus commented Jan 3, 2026

Nice.

My main issue with your approach is that you're serializing the values. The native multi-select would just send multiple values. I don't think I want to depart from native HTML behaviors.

I'm reworking your solution as I need a multi-select field in devpu.sh. I'll report back here asap.

@hunvreus
Copy link
Copy Markdown
Owner

hunvreus commented Jan 3, 2026

I do want to support the more complex visualization/interaction with chips at some point, but I'm gonna ship a simpler version for now. This one works with what we already have with limited changes.

CleanShot.2026-01-03.at.15.23.04.mp4

@hunvreus
Copy link
Copy Markdown
Owner

hunvreus commented Jan 4, 2026

Shipped in basecoat-css@0.3.10-beta.1, you can try it out.

I will provide an implementation for the chips, but it will be built with some custom JS/CSS off of the current component. I don't think I will ship this as a core component in Basecoat.

@hunvreus
Copy link
Copy Markdown
Owner

hunvreus commented Jan 5, 2026

I'll have.a poc for the full chips + remove button later on this week. For this I need to allow the select component to define custom triggers. Not hard, but need to be careful what I ship. I have a rough idea how.

@hunvreus
Copy link
Copy Markdown
Owner

hunvreus commented Jan 9, 2026

After testing, I realized that serializing the values is way more robust as well, but in the front-end and backend. You were right @sebastiangrill. Shipped it in 0.3.10-beta.2.

Still holding off on the more complex visualization but I have a plan.

@ijustw0rkhere
Copy link
Copy Markdown

i'm a big fan of searchable, clearable (all items), tag/pill (with individual click to clear) multiselect components. really appreciate the efforts on this

@hunvreus
Copy link
Copy Markdown
Owner

Actually, most of it is already there:

I will create a meta component that uses the current multi-select but displays selected items as chips with a close icon and tie it all together. I don't want it to be implemented as part of core though, because it requires a lot more logic.

@sebastiangrill
Copy link
Copy Markdown
Author

Thanks a lot. I have tried it for a few days and it looks good. Covers around 80% of my use cases.
The only thing that's a little annoying is that the hidden input never sends a change event.
Frameworks like Datastar listen for change events on input elements to sync signals/variables, but there is a workaround.
Excited for the chip version.

@hunvreus
Copy link
Copy Markdown
Owner

The only thing that's a little annoying is that the hidden input never sends a change event.

Oh that's no good. I'm gonna fix that.

Excited for the chip version.

I'll try and get something out this week.

@hunvreus
Copy link
Copy Markdown
Owner

Wait, the component is sending an event though, no?

You should see CustomEvent('change', { detail: { value } }) being thrown when you make changes to the selection. It's documented here: http://localhost:8080/components/select/#usage-html-js-4

I know it's not a native change event but I've tried a lot of different approaches and the serialized hidden input was the best tradeoff.

@sebastiangrill
Copy link
Copy Markdown
Author

Ah sorry for the confusion, yes the component is sending an event.
Datastar binds to the value of an element and updates the local variables on a change event.
But the select component itself has no value so you can't bind the signal/var there. It requires some manual wiring to get it working. Just a little annoying but I can understand why you did it this way.
If the input would send a change event you would just need to put a data-bind: on it and have the value available in the backend.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants